@ampless/runtime 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ampless contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # @ampless/runtime
2
+
3
+ Public-side runtime for [ampless](https://github.com/heavymoons/ampless). Bundles the post-fetching client, site settings, theme resolution, SEO metadata aggregation, middleware, and public route handlers behind a single `createAmpless()` factory.
4
+
5
+ > **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0.
6
+
7
+ Splitting this out of the template lets you `npm update @ampless/runtime` without touching scaffolded files — public-site behaviour upgrades come in through the package, not by re-running the scaffolder.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @ampless/runtime@alpha ampless@alpha
13
+ ```
14
+
15
+ `@ampless/runtime` declares peer dependencies on `next` (15+), `react` (18/19), `aws-amplify` (6+), and `@aws-amplify/adapter-nextjs` (1+). The CLI scaffolder pins compatible versions in the template's `package.json`.
16
+
17
+ ## Usage
18
+
19
+ Templates create one shared instance at `lib/ampless.ts`:
20
+
21
+ ```ts
22
+ import outputs from '../amplify_outputs.json'
23
+ import cmsConfig from '../cms.config'
24
+ import { themes, DEFAULT_THEME } from '../themes-registry'
25
+ import { createAmpless } from '@ampless/runtime'
26
+
27
+ export const ampless = createAmpless({
28
+ outputs,
29
+ cmsConfig,
30
+ themes: { themes, defaultTheme: DEFAULT_THEME },
31
+ })
32
+ ```
33
+
34
+ Routes and dispatchers under `app/site/[siteId]/` become one-line factory invocations:
35
+
36
+ ```ts
37
+ // app/site/[siteId]/page.tsx
38
+ import { ampless } from '@/lib/ampless'
39
+ import {
40
+ createThemeHomeDispatcher,
41
+ createThemeHomeMetadata,
42
+ } from '@ampless/runtime/dispatchers'
43
+
44
+ export const dynamic = 'force-dynamic'
45
+ export const generateMetadata = createThemeHomeMetadata(ampless)
46
+ export default createThemeHomeDispatcher(ampless)
47
+ ```
48
+
49
+ ```ts
50
+ // app/site/[siteId]/og/[slug]/route.ts
51
+ import { ampless } from '@/lib/ampless'
52
+ import { createOgRouteHandler } from '@ampless/runtime/routes'
53
+
54
+ export const runtime = 'nodejs'
55
+ export const dynamic = 'force-dynamic'
56
+ export const GET = createOgRouteHandler(ampless)
57
+ ```
58
+
59
+ Middleware:
60
+
61
+ ```ts
62
+ // middleware.ts
63
+ import cmsConfig from './cms.config'
64
+ import { createAmplessMiddleware, defaultMatcherConfig } from '@ampless/runtime/middleware'
65
+
66
+ export const middleware = createAmplessMiddleware({ cmsConfig })
67
+ export const config = defaultMatcherConfig
68
+ ```
69
+
70
+ ## Sub-paths
71
+
72
+ - `@ampless/runtime` — `createAmpless`, runtime types, and re-exports of `renderBody`, `renderThemeCss`, format converters
73
+ - `@ampless/runtime/middleware` — `createAmplessMiddleware`, `defaultMatcherConfig`
74
+ - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createRawRouteHandler`
75
+ - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`, `createThemePostDispatcher`, `createThemeTagDispatcher` (each with a matching `*Metadata` factory)
76
+
77
+ ## What's still in the template
78
+
79
+ Admin-side modules (post providers, theme actions, auth, kv writes) and theme components stay in the scaffold. They move into `@ampless/admin` in a later release; until then, edits to those files belong in the user's project.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,59 @@
1
+ import { Metadata } from 'next';
2
+ import { Ampless } from '../index.js';
3
+ import 'ampless';
4
+
5
+ interface Props$2 {
6
+ params: Promise<{
7
+ siteId: string;
8
+ }>;
9
+ }
10
+ type ThemeHomeDispatcher = (props: Props$2) => Promise<unknown>;
11
+ type ThemeHomeMetadata = (props: Props$2) => Promise<Metadata>;
12
+ /**
13
+ * Home page dispatcher. Resolves the active theme for the request's
14
+ * siteId and renders the theme's `components.Home` server component
15
+ * with the same `params` Promise it was passed.
16
+ *
17
+ * The return type is `unknown` (cast at the route boundary) because
18
+ * Next.js page-component prop types vary by route shape and the
19
+ * underlying theme components are arbitrary server components.
20
+ */
21
+ declare function createThemeHomeDispatcher(ampless: Ampless): ThemeHomeDispatcher;
22
+ /** generateMetadata factory for the home dispatcher. */
23
+ declare function createThemeHomeMetadata(ampless: Ampless): ThemeHomeMetadata;
24
+
25
+ interface Props$1 {
26
+ params: Promise<{
27
+ siteId: string;
28
+ slug: string;
29
+ }>;
30
+ }
31
+ type ThemePostDispatcher = (props: Props$1) => Promise<unknown>;
32
+ type ThemePostMetadata = (props: Props$1) => Promise<Metadata>;
33
+ /**
34
+ * Post page dispatcher. Resolves the active theme and renders the
35
+ * theme's `components.Post` server component. If the theme doesn't
36
+ * declare a Post component, returns Next.js's notFound() (404).
37
+ */
38
+ declare function createThemePostDispatcher(ampless: Ampless): ThemePostDispatcher;
39
+ /** generateMetadata factory for the post dispatcher. */
40
+ declare function createThemePostMetadata(ampless: Ampless): ThemePostMetadata;
41
+
42
+ interface Props {
43
+ params: Promise<{
44
+ siteId: string;
45
+ tag: string;
46
+ }>;
47
+ }
48
+ type ThemeTagDispatcher = (props: Props) => Promise<unknown>;
49
+ type ThemeTagMetadata = (props: Props) => Promise<Metadata>;
50
+ /**
51
+ * Tag page dispatcher. Resolves the active theme and renders the
52
+ * theme's `components.Tag` server component. If the theme doesn't
53
+ * declare a Tag component, returns Next.js's notFound() (404).
54
+ */
55
+ declare function createThemeTagDispatcher(ampless: Ampless): ThemeTagDispatcher;
56
+ /** generateMetadata factory for the tag dispatcher. */
57
+ declare function createThemeTagMetadata(ampless: Ampless): ThemeTagMetadata;
58
+
59
+ export { type ThemeHomeDispatcher, type ThemeHomeMetadata, type ThemePostDispatcher, type ThemePostMetadata, type ThemeTagDispatcher, type ThemeTagMetadata, createThemeHomeDispatcher, createThemeHomeMetadata, createThemePostDispatcher, createThemePostMetadata, createThemeTagDispatcher, createThemeTagMetadata };
@@ -0,0 +1,65 @@
1
+ // src/dispatchers/home.ts
2
+ function createThemeHomeDispatcher(ampless) {
3
+ return async function SiteHomeDispatcher({ params }) {
4
+ const { siteId } = await params;
5
+ const { module } = await ampless.resolveActiveTheme(siteId);
6
+ const Home = module.components.Home;
7
+ return await Home({ params });
8
+ };
9
+ }
10
+ function createThemeHomeMetadata(ampless) {
11
+ return async function generateMetadata({ params }) {
12
+ const { siteId } = await params;
13
+ const { module } = await ampless.resolveActiveTheme(siteId);
14
+ const fn = module.metadata?.Home;
15
+ return fn ? await fn({ params }) : {};
16
+ };
17
+ }
18
+
19
+ // src/dispatchers/post.ts
20
+ import { notFound } from "next/navigation";
21
+ function createThemePostDispatcher(ampless) {
22
+ return async function SitePostDispatcher({ params }) {
23
+ const { siteId } = await params;
24
+ const { module } = await ampless.resolveActiveTheme(siteId);
25
+ const Post = module.components.Post;
26
+ if (!Post) notFound();
27
+ return await Post({ params });
28
+ };
29
+ }
30
+ function createThemePostMetadata(ampless) {
31
+ return async function generateMetadata({ params }) {
32
+ const { siteId } = await params;
33
+ const { module } = await ampless.resolveActiveTheme(siteId);
34
+ const fn = module.metadata?.Post;
35
+ return fn ? await fn({ params }) : {};
36
+ };
37
+ }
38
+
39
+ // src/dispatchers/tag.ts
40
+ import { notFound as notFound2 } from "next/navigation";
41
+ function createThemeTagDispatcher(ampless) {
42
+ return async function SiteTagDispatcher({ params }) {
43
+ const { siteId } = await params;
44
+ const { module } = await ampless.resolveActiveTheme(siteId);
45
+ const Tag = module.components.Tag;
46
+ if (!Tag) notFound2();
47
+ return await Tag({ params });
48
+ };
49
+ }
50
+ function createThemeTagMetadata(ampless) {
51
+ return async function generateMetadata({ params }) {
52
+ const { siteId } = await params;
53
+ const { module } = await ampless.resolveActiveTheme(siteId);
54
+ const fn = module.metadata?.Tag;
55
+ return fn ? await fn({ params }) : {};
56
+ };
57
+ }
58
+ export {
59
+ createThemeHomeDispatcher,
60
+ createThemeHomeMetadata,
61
+ createThemePostDispatcher,
62
+ createThemePostMetadata,
63
+ createThemeTagDispatcher,
64
+ createThemeTagMetadata
65
+ };
@@ -0,0 +1,206 @@
1
+ import { Post, ThemeModule, ThemeManifest, Config } from 'ampless';
2
+ export { Config, Post, ThemeManifest } from 'ampless';
3
+ import { Metadata } from 'next';
4
+
5
+ interface StorageOutput {
6
+ bucket_name: string;
7
+ aws_region: string;
8
+ }
9
+ interface DataOutput {
10
+ url: string;
11
+ aws_region: string;
12
+ default_authorization_type?: string;
13
+ api_key?: string;
14
+ }
15
+ interface AmplessOutputs {
16
+ storage?: StorageOutput;
17
+ data?: DataOutput;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ interface StorageApi {
22
+ publicAssetUrl(key: string): string;
23
+ isStorageConfigured(): boolean;
24
+ }
25
+
26
+ interface PublicPostShape {
27
+ postId: string;
28
+ siteId: string;
29
+ slug: string;
30
+ title: string;
31
+ excerpt?: string | null;
32
+ format?: string | null;
33
+ body?: unknown;
34
+ status?: string | null;
35
+ publishedAt?: string | null;
36
+ tags?: Array<string | null> | null;
37
+ }
38
+ interface PublicPostConnectionShape {
39
+ items?: Array<PublicPostShape | null> | null;
40
+ nextToken?: string | null;
41
+ }
42
+ interface ListPostsOptions {
43
+ siteId?: string;
44
+ /** ISO 8601 timestamp; SK lower bound (inclusive). */
45
+ from?: string;
46
+ /** ISO 8601 timestamp; SK upper bound (inclusive). */
47
+ to?: string;
48
+ limit?: number;
49
+ /** Opaque cursor returned by a previous call. */
50
+ nextToken?: string;
51
+ }
52
+ interface ListPostsByTagOptions {
53
+ siteId?: string;
54
+ limit?: number;
55
+ nextToken?: string;
56
+ }
57
+ interface ListPostsResult {
58
+ items: Post[];
59
+ nextToken: string | null;
60
+ }
61
+ interface PostsApi {
62
+ listPublishedPosts(opts?: ListPostsOptions): Promise<ListPostsResult>;
63
+ getPublishedPost(slug: string, opts?: {
64
+ siteId?: string;
65
+ }): Promise<Post | null>;
66
+ listPostsByTag(tag: string, opts?: ListPostsByTagOptions): Promise<ListPostsResult>;
67
+ }
68
+
69
+ interface EffectiveSiteSettings {
70
+ site: {
71
+ name: string;
72
+ url: string;
73
+ description?: string;
74
+ };
75
+ media: {
76
+ imageDisplay?: 'inline' | 'lightbox';
77
+ imageMaxWidth?: string;
78
+ processing?: {
79
+ maxDimension?: number;
80
+ format?: 'webp' | 'jpeg' | 'original';
81
+ quality?: number;
82
+ losslessForPng?: boolean;
83
+ };
84
+ };
85
+ dateFormat?: 'iso' | 'long' | 'locale';
86
+ timezone?: string;
87
+ }
88
+ interface SiteSettingsApi {
89
+ loadSiteSettings(siteId?: string): Promise<EffectiveSiteSettings>;
90
+ }
91
+
92
+ interface SeoApi {
93
+ postMetadata(post: Post, siteId?: string): Promise<Metadata>;
94
+ siteMetadata(siteId?: string): Promise<Metadata>;
95
+ }
96
+
97
+ interface ThemesRegistry {
98
+ /** Map of theme name → loaded theme module. */
99
+ themes: Record<string, ThemeModule>;
100
+ /** Name used when no `theme.active` override is stored for the site. */
101
+ defaultTheme: string;
102
+ }
103
+ interface ResolvedTheme {
104
+ name: string;
105
+ module: ThemeModule;
106
+ }
107
+ interface ThemeActiveApi {
108
+ resolveActiveTheme(siteId?: string): Promise<ResolvedTheme>;
109
+ }
110
+
111
+ interface EffectiveThemeConfig {
112
+ /** Resolved active theme name (e.g. 'blog'). */
113
+ activeTheme: string;
114
+ manifest: ThemeManifest;
115
+ /** Resolved values, keyed by manifest field key. Always populated. */
116
+ values: Record<string, string>;
117
+ /** Subset of `values` for fields that have a `cssVar`. */
118
+ cssVars: Record<string, string>;
119
+ }
120
+ interface ThemeConfigApi {
121
+ loadThemeConfig(siteId?: string): Promise<EffectiveThemeConfig>;
122
+ }
123
+ /**
124
+ * Render `cssVars` as the body of a `:root { ... }` CSS block. Values
125
+ * have already been validated by `resolveThemeValues`, so the output is
126
+ * safe to inline via `dangerouslySetInnerHTML`.
127
+ */
128
+ declare function renderThemeCss(cssVars: Record<string, string>): string;
129
+
130
+ declare function renderBody(post: Post): string;
131
+ /**
132
+ * Convert a tiptap doc to its HTML form. Same renderer the public
133
+ * site uses. Defensive: tiptap accepts an HTML string as initial
134
+ * content and parses it on mount, but won't fire onUpdate until the
135
+ * user edits — so a format-switch chain (e.g. markdown → tiptap →
136
+ * markdown without editing) can still hand us a raw HTML string
137
+ * here. In that case, return it as-is rather than walking it as a
138
+ * malformed tiptap node and producing empty output.
139
+ */
140
+ declare function tiptapToHtml(doc: unknown): string;
141
+ /** Convert markdown to HTML using the built-in minimal renderer. */
142
+ declare function markdownToHtml(md: string): string;
143
+ /**
144
+ * Walk a tiptap doc and emit Markdown. Mirrors `renderTiptap` in
145
+ * shape but produces markdown syntax. Loses anything markdown can't
146
+ * express (data attributes, image display modes, custom marks).
147
+ *
148
+ * Same defensive path as tiptapToHtml: a string input means tiptap
149
+ * hasn't emitted JSON yet (the body is still the HTML we handed it).
150
+ * Route through htmlToMarkdown so the content survives.
151
+ */
152
+ declare function tiptapToMarkdown(doc: unknown): string;
153
+ /**
154
+ * Regex-based HTML → Markdown converter. Handles the tag set the
155
+ * editor produces (`<p>` `<h1>`-`<h6>` `<strong>` `<em>` `<a>`
156
+ * `<img>` `<ul>` `<ol>` `<li>` `<code>` `<pre>` `<blockquote>` `<hr>`
157
+ * `<br>`). Anything else (tables, sections, divs) keeps its content
158
+ * but loses structural meaning.
159
+ *
160
+ * Not a full library — there are known limits like nested formatting
161
+ * inside list items potentially merging. Acceptable for a v0.x
162
+ * format-switch convenience; complex HTML round-trips shouldn't be
163
+ * relied on.
164
+ */
165
+ declare function htmlToMarkdown(html: string): string;
166
+
167
+ interface CreateAmplessOpts {
168
+ outputs: AmplessOutputs;
169
+ cmsConfig: Config;
170
+ themes: ThemesRegistry;
171
+ }
172
+ interface Ampless {
173
+ listPublishedPosts(opts?: ListPostsOptions): Promise<ListPostsResult>;
174
+ getPublishedPost(slug: string, opts?: {
175
+ siteId?: string;
176
+ }): Promise<Post | null>;
177
+ listPostsByTag(tag: string, opts?: ListPostsByTagOptions): Promise<ListPostsResult>;
178
+ loadSiteSettings(siteId?: string): Promise<EffectiveSiteSettings>;
179
+ resolveActiveTheme(siteId?: string): Promise<ResolvedTheme>;
180
+ loadThemeConfig(siteId?: string): Promise<EffectiveThemeConfig>;
181
+ postMetadata(post: Post, siteId?: string): Promise<Metadata>;
182
+ siteMetadata(siteId?: string): Promise<Metadata>;
183
+ renderBody(post: Post): string;
184
+ renderThemeCss(cssVars: Record<string, string>): string;
185
+ publicAssetUrl(key: string): string;
186
+ isStorageConfigured(): boolean;
187
+ readonly outputs: AmplessOutputs;
188
+ readonly cmsConfig: Config;
189
+ readonly themes: ThemesRegistry;
190
+ readonly posts: PostsApi;
191
+ readonly settings: SiteSettingsApi;
192
+ readonly seo: SeoApi;
193
+ readonly themeActive: ThemeActiveApi;
194
+ readonly themeConfig: ThemeConfigApi;
195
+ readonly storageApi: StorageApi;
196
+ }
197
+ /**
198
+ * Wire up the ampless runtime from user-supplied config blobs. The
199
+ * returned `Ampless` instance is the single object handed to route
200
+ * dispatchers / metadata factories / middleware — each sub-API
201
+ * (posts, settings, themes, ...) is also available individually on
202
+ * the instance for cases where a thin handler only needs one piece.
203
+ */
204
+ declare function createAmpless(opts: CreateAmplessOpts): Ampless;
205
+
206
+ export { type Ampless, type AmplessOutputs, type CreateAmplessOpts, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type PostsApi, type PublicPostConnectionShape, type PublicPostShape, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, createAmpless, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, tiptapToHtml, tiptapToMarkdown };